# Copyright (c) 2018 Presto Labs Pte. Ltd.
# Author: xguo

import enum
import logging
import math
import typing

import requests
import tornado.gen

import coin.flow.subscriber as flow_subscriber
import coin.flow.topic_map as topic_map

from coin.base.config import Config
from coin.exchange.base.order_fill_util import FillManager
from coin.exchange.base.rest.task_scheduler import Scheduler, wrap_async_task
from coin.exchange.util.rest_rate_limit import RestRateLimiter
from coin.proto.coin_order_gateway_pb2 import OrderGatewayConfig
from coin.proto.coin_query_pb2 import AccountBalance

from coin.exchange.quoinex_v2.kr_rest.private_client import (
    QuoinexPrivateParser,
    QuoinexProduct,
    QuoinexCurrency,
)
from coin.exchange.quoinex_v2.kr_rest.native_private_client import (
    QuoinexNativePrivateClient,
    to_quoinex_order_side,
    from_quoinex_order_side,
    from_quoinex_order_type,
    QuoinexOrderType,
    QuoinexOrderSide,
    QuoinexOrderStatus,
)
from coin.exchange.base.rest_order_gateway_base import (
    RestOrderGateway,
    exchange_message_handler,
    create_order_internal,
    OrderEvent,
    Order,
    OrderType,
    BaseBalanceOrderGatewayInfo,
    PrivateExchangeMessage,
    ProductFillElement,
    to_proto_order_side,
    coin_order_pb2,
    pretty_json,
)


def create_order_from_order_info(order_msg, timestamp, order_id=None):
  """
  {
      "id": 1030292341,
      "order_type": "limit",
      "quantity": "0.01",
      "disc_quantity": "0.0",
      "iceberg_total_quantity": "0.0",
      "side": "sell",
      "filled_quantity": "0.01",
      "price": 621848.35,
      "created_at": 1557132410,
      "updated_at": 1557132426,
      "status": "filled",
      "leverage_level": 1,
      "source_exchange": null,
      "product_id": 5,
      "product_code": "CASH",
      "funding_currency": "JPY",
      "crypto_account_id": null,
      "currency_pair_code": "BTCJPY",
      "average_price": 621848.35,
      "target": "spot",
      "order_fee": "6.21848",
      "source_action": "manual",
      "unwound_trade_id": null,
      "trade_id": null,
      "settings": null,
      "trailing_stop_type": null,
      "trailing_stop_value": null,
      "executions": [
        {
          "id": 117338633,
          "quantity": "0.01",
          "price": "621848.35",
          "taker_side": "buy",
          "created_at": 1557132426,
          "my_side": "sell"
        }
      ],
      "stop_triggered_time": null
  }
  """
  price_orig = float(order_msg['price'])
  qty_orig = float(order_msg['quantity'])
  order_side = from_quoinex_order_side(QuoinexOrderSide(order_msg['side']))
  order_type = from_quoinex_order_type(QuoinexOrderType(order_msg['order_type']))
  exchange_order_id = str(order_msg['id'])
  product = QuoinexProduct.FromStrNativeProduct(order_msg['currency_pair_code'])
  status = QuoinexOrderStatus(order_msg['status'])
  if order_msg['order_fee'] is None:
    fee = 0
  else:
    fee = float(order_msg['order_fee'])

  fill = FillManager(
      qty_orig=qty_orig,
      price_orig=price_orig,
      qty_accum_fill=0.0,
      price_avg_fill=None,
      qty_last_fill=None,
      price_last_fill=None,
  )

  executions = []
  fill_id_set = set()  # Sometimes Quoinex gives duplicated fill msgs.
  for execution_info in order_msg.get('executions', []):
    fill_id = '%s_%s' % (product.symbol, execution_info['id'])
    if fill_id in fill_id_set:
      # Ignore
      continue
    else:
      fill_id_set.add(fill_id)

    price_last_fill = float(execution_info['price'])
    qty_last_fill = float(execution_info['quantity'])
    fill.update_by_last_fill(price_last_fill, qty_last_fill)
    taker_side = execution_info['taker_side']
    my_side = execution_info['my_side']

    if my_side == taker_side:
      fill_type = coin_order_pb2.TAKER_FILL_TYPE
    else:
      fill_type = coin_order_pb2.MAKER_FILL_TYPE

    executions.append({
        'price': price_last_fill,
        'qty': qty_last_fill,
        'fill_id': fill_id,
        'fill_type': fill_type,
    })

  qty_accum_fill = float(order_msg['filled_quantity'])
  if not math.isclose(fill.value.qty_accum_fill, qty_accum_fill):
    logging.error("order info's filled_qty is not consistent! %s", pretty_json(order_msg))

  order = Order(order_id=order_id,
                product=product,
                price=price_orig,
                qty=qty_orig,
                order_side=order_side,
                order_type=order_type,
                tag={'executions': executions},
                accepted=True,
                cancel_sent=False,
                internal=create_order_internal(
                    timestamp=timestamp,
                    exchange_order_id=exchange_order_id,
                    fill=fill,
                    status=status,
                    fee=fee,
                ))
  return order


class QuoinexOrderGatewayInfo(BaseBalanceOrderGatewayInfo):
  CurrencyType = QuoinexCurrency
  ProductType = QuoinexProduct

  def update_balance(self, update_msg):
    account_balance = QuoinexPrivateParser.parse_balance(update_msg)
    for balance in account_balance.each_balance:
      currency = QuoinexCurrency.FromStrCurrency(balance.currency)
      if currency in self.currencies:
        self.set_balance(currency, balance.total, balance)

  def balance_as_proto(self):
    return AccountBalance(exchange='Quoinex',
                          market_type='Spot',
                          each_balance=self.get_notNone_balances_internal())


class QuoinexOrderGateway(RestOrderGateway):
  _exchange = 'Quoinex'
  _market_type = 'Spot'
  _api_version = None

  OGInfoType = QuoinexOrderGatewayInfo

  class _MsgType(enum.IntEnum):
    REST_ACCOUNT_BALANCE = 1
    REST_RECENT_ORDERS = 2

  def __init__(self,
               currencies: typing.List[QuoinexCurrency],
               products: typing.List[QuoinexProduct],
               config: Config,
               og_config=None,
               logger=None):
    super().__init__(currencies, products, config, og_config, logger)

    self._private_client = None
    self._scheduler = None
    self._rest_rate_limiter = RestRateLimiter(delay=300)

    self._ioloop = None
    self._flow_sub = None
    self._publisher = None

    self._og_config = OrderGatewayConfig(
        account_query_period=2,
        orders_snapshot_query_period=4,
        use_fill_checker=True,
    )
    if og_config is not None:
      self._og_config.MergeFrom(og_config)

  def submittable(self):
    return self.is_ready() and self._rest_rate_limiter.is_available()

  def start(self, queue, ioloop, flow_sub=None):
    api_host = self._og_config.rest_root_url \
               if self._og_config.HasField('rest_root_url') else None
    self._private_client = QuoinexNativePrivateClient(
        key_file=self._config.key_file,
        max_connections=self._og_config.max_connections,
        api_host=api_host,
        use_async=True,
        rest_request_gap_sec=self._og_config.rest_request_gap_sec,
    )
    topic_id = topic_map.search_topic_id('quoinex_rest_private_1')[0][1]
    self._ioloop = ioloop
    self._publisher = queue.get_writer(topic_id=topic_id)
    self._scheduler = Scheduler(ioloop=ioloop, logger=self._logger)
    self._install_periodic_query()

    # Subscribe to flow for feed.
    self._flow_sub = flow_sub or flow_subscriber.from_queue(queue)
    self._flow_sub.subscribe('quoinex_rest_private_1', self.on_private_exchange_msg)

  @tornado.gen.coroutine
  def _install_periodic_query(self):
    assert self._scheduler is not None, 'self._scheduler is not set!'
    assert self._publisher is not None, 'self._publisher is not set!'

    # query balance
    query_balance_task = wrap_async_task(
        self._private_client.query_get_all_account_balances,
        msg_type=self._MsgType.REST_ACCOUNT_BALANCE,
        publisher=self._publisher,
    )
    self._scheduler.add_periodic_task(
        period=self._og_config.account_query_period,
        job_func=query_balance_task,
    )

    yield tornado.gen.sleep(self._og_config.burst_query_avoid_time)
    # query working orders/dead orders and executions
    query_orders_task = wrap_async_task(
        self._private_client.query_get_orders,
        msg_type=self._MsgType.REST_RECENT_ORDERS,
        publisher=self._publisher,
    )
    self._scheduler.add_periodic_task(
        period=self._og_config.orders_snapshot_query_period,
        job_func=query_orders_task,
    )
    self.start_scheduler()

  def _async_submit_order(self, order, post_only=False):
    if post_only:
      self._logger.warning('post only not supported.')
    assert order.order_id is not None, order
    assert order.order_type == OrderType.LIMIT, order

    formatter = self._order_formatter[order.product]
    params = {
        'product_id': order.product.product_id,
        'price': formatter.format_price(order.price),
        'quantity': formatter.format_qty(order.qty),
        'order_side': to_quoinex_order_side(order.order_side),
        'order_type': QuoinexOrderType.LIMIT,
    }
    fut = self._private_client.query_create_an_order(**params)
    return fut

  def _async_cancel_order(self, order):
    assert order.internal.exchange_order_id is not None, order
    exchange_order_id = int(order.internal.exchange_order_id)
    fut = self._private_client.query_cancel_an_order(exchange_order_id)
    return fut

  def _handle_submit_response(self, order, response: requests.Response):
    """ {
    "id": 2157474,
    "order_type": "limit",
    "quantity": "0.01",
    "disc_quantity": "0.0",
    "iceberg_total_quantity": "0.0",
    "side": "sell",
    "filled_quantity": "0.0",
    "price": "500.0",
    "created_at": 1462123639,
    "updated_at": 1462123639,
    "status": "live",
    "leverage_level": 1,
    "source_exchange": "QUOINE",
    "product_id": 1,
    "product_code": "CASH",
    "funding_currency": "USD",
    "currency_pair_code": "BTCUSD",
    "order_fee": "0.0" }
    """
    if response.status_code != requests.codes.ok:
      self._handle_error_response(response)
      self._order_manager.remove_order(order)
      return OrderEvent.ORDER_REJECTED

    msg = response.json()
    exchange_order_id = str(msg['id'])
    status = QuoinexOrderStatus(msg['status'])
    prev_order = self._order_manager.get(exchange_order_id=exchange_order_id)
    if prev_order is None:
      self._order_manager.set_order(order, exchange_order_id=exchange_order_id)
    else:
      self._logger.info('order id replaced: %s => %s', prev_order.order_id, order.order_id)
      self._order_manager.remove_order(order)
      self._order_manager.remove_order(prev_order)
      prev_order.order_id = order.order_id
      self._order_manager.add_order(prev_order)

    if status == QuoinexOrderStatus.FILLED:
      self._logger.info('Submitted order filled already! %s', order.order_id)
    elif status == QuoinexOrderStatus.PARTIALLY_FILLED:
      self._logger.info('Submitted order partially filled! %s', order.order_id)
    elif status == QuoinexOrderStatus.CANCELED:
      self._logger.info('Unexpected order status: %s', status)
    return OrderEvent.ORDER_ACCEPTED

  def _handle_cancel_response(self, order, response: requests.Response):
    """{
    "id": 2157474,
    "order_type": "limit",
    "quantity": "0.01",
    "disc_quantity": "0.0",
    "iceberg_total_quantity": "0.0",
    "side": "sell",
    "filled_quantity": "0.0",
    "price": "500.0",
    "created_at": 1462123639,
    "updated_at": 1462123639,
    "status": "cancelled",
    "leverage_level": 1,
    "source_exchange": "QUOINE",
    "product_id": 1,
    "product_code": "CASH",
    "funding_currency": "USD",
    "currency_pair_code": "BTCUSD" }
    """
    if response.status_code != requests.codes.ok:
      self._handle_error_response(response)
      return OrderEvent.CANCEL_ERROR

    msg = response.json()
    exchange_order_id = str(msg['id'])
    status = QuoinexOrderStatus(msg['status'])
    if order.internal.exchange_order_id != exchange_order_id:
      self._logger.error('Exchange order does not match, cancel failed %s, %s',
                         exchange_order_id,
                         order)
      order.cancel_sent = False
      return OrderEvent.CANCEL_ERROR

    if status == QuoinexOrderStatus.CANCELED:
      if not self._order_manager.is_dead_order(order):
        self._order_manager.set_order(order, cancel_confirmed=True)
      else:
        self._logger.warning('Order is dead already! %s', order.order_id)
      return OrderEvent.CANCEL_CONFIRMED
    elif status == QuoinexOrderStatus.FILLED:
      self._logger.error('Order filled already! %s', msg)
      return OrderEvent.CANCEL_CONFIRMED
    else:
      self._logger.error('Cancel failed! %s', msg)
      order.cancel_sent = False
      return OrderEvent.CANCEL_ERROR

  @exchange_message_handler(_MsgType.REST_ACCOUNT_BALANCE)
  def _handle_account_balance(self, exchange_message: PrivateExchangeMessage):
    response = exchange_message.data
    if response.status_code != requests.codes.ok:
      self._handle_error_response(response)
      return

    update_msg = response.json()
    self._og_info.update_balance(update_msg)
    self._og_logger.write_balance(self._og_info.balance_as_proto())
    if not self.is_ready():
      self._og_ready.set_balance_ready(True)
      self._og_ready.check_ready()

  @exchange_message_handler(_MsgType.REST_RECENT_ORDERS)
  def _handle_recent_orders_info(self, exchange_message: PrivateExchangeMessage):
    response = exchange_message.data
    timestamp = exchange_message.received_timestamp
    if response.status_code != requests.codes.ok:
      self._handle_error_response(response)
      return

    update_msg = response.json()
    is_first_query = not self._processed_fills
    if is_first_query:
      self._processed_fills.add(None)

    exchange_order_dict = {}
    for order_msg in update_msg['models']:
      order = create_order_from_order_info(order_msg, timestamp)
      if order.product not in self._og_info.products:
        continue

      exchange_order_id = order.internal.exchange_order_id
      prev_order = self._order_manager.get(exchange_order_id=exchange_order_id)
      if prev_order is None:
        order_id = self._order_id_generator.gen_order_id(is_foreign=True)
        order.order_id = order_id
      else:
        order.order_id = prev_order.order_id

      if order.internal.status in (QuoinexOrderStatus.LIVE, QuoinexOrderStatus.PARTIALLY_FILLED):
        exchange_order_dict[exchange_order_id] = order
      self._update_order(order, timestamp, is_first_query)

    self._order_manager.copy_from_exchange_open_orders(exchange_order_dict=exchange_order_dict,
                                                       products=self._og_info.products)

    if not self.is_ready():
      self._og_ready.set_orders_ready(True)
      self._og_ready.check_ready()

  def _update_order(self, order_info, timestamp, is_first_query):
    exchange_order_id = order_info.internal.exchange_order_id
    status = order_info.internal.status
    order = self._order_manager.get(exchange_order_id=exchange_order_id)

    # Handle foreign order.
    if order is None:
      self._order_manager.add_order(order_info)
      order = order_info

    if not order.accepted:
      self._order_manager.set_order(exchange_order_id=exchange_order_id)

    if status == QuoinexOrderStatus.CANCELED:
      if not self._order_manager.is_dead_order(order):
        self._order_manager.set_order(order, cancel_confirmed=True)
    elif status == QuoinexOrderStatus.FILLED:
      if not self._order_manager.is_dead_order(order):
        self._order_manager.set_order(order, fully_filled=True)
    elif status in (QuoinexOrderStatus.LIVE, QuoinexOrderStatus.PARTIALLY_FILLED):
      if self._order_manager.is_dead_order(order):
        self._logger.info('Order is already marked as dead, %s', order.order_id)
      else:
        if order.internal.exchange_order_id != exchange_order_id:
          # Should never happen
          self._logger.error('Inconsistent exchange_order_id, %s\n%s', exchange_order_id, order)
    else:
      self._logger.error('Unknown order status: %s', status)

    if is_first_query:
      for execution_info in order_info.tag['executions']:
        fill_id = execution_info['fill_id']
        self._processed_fills.add(fill_id)
      return

    for execution_info in order_info.tag['executions']:
      fill_id = execution_info['fill_id']
      if fill_id in self._processed_fills:
        continue

      price_last_fill = execution_info['price']
      qty_last_fill = execution_info['qty']
      last_fill = order.internal.fill.update_by_last_fill(price_last_fill, qty_last_fill)

      if last_fill is None:
        self._logger.error('Weird! LastFill is None! %s', order_info)
        return

      fill_type = execution_info['fill_type']
      fill_proto = ProductFillElement(fill_id=fill_id,
                                      price=last_fill.price_last_fill,
                                      qty=last_fill.qty_last_fill,
                                      order_id=order.order_id,
                                      side=to_proto_order_side(order.order_side),
                                      exchange_order_id=exchange_order_id,
                                      fill_type=fill_type,
                                      symbol=order.product.symbol)
      self.process_fill(fill_proto, timestamp)

  def _handle_error_response(self, response):
    if response.status_code >= 500:
      self._logger.error('5xx server error! %s, %s, %s',
                         response.status_code,
                         response.content,
                         response.url)
    elif response.status_code == 429:
      self._rest_rate_limiter.set_error(status_code=response.status_code)
      self._logger.error('Rate limit hit (429) OG will be disabled for 5 min! %s, %s, %s',
                         response.status_code,
                         response.content,
                         response.url)
    elif response.status_code == 401:
      self._logger.error('Nonce error! %s, %s, %s',
                         response.status_code,
                         response.content,
                         response.url)
    else:
      self._logger.error('Rest error response: %s, %s, %s',
                         response.status_code,
                         response.content,
                         response.url)
